iT邦幫忙

2021 iThome 鐵人賽

DAY 2
3

本節是以 Golang 上游 1a708bcf1d17171056a42ec1597ca8848c854d2a 為基準做的實驗。

予焦啦!不管是什麼樣的系統或什麼樣的程式,只要是用編譯式語言,就必須使用編譯器以降的工具鏈(toolchain)先行建置原始碼,獲取建置產物之後,才能夠真正使用。這個章節的目的即是展示,我們應當如何為 Hoddarla 專案準備它所需要的工具鏈。

既然 Hoddarla 將採用 Golang 開發,那麼理想上就應該允許開發者使用以下指令

go build ./

然而這麼做的盲點在於,Golang 的建置(build)指令在沒有給定 GOOSGOARCH 兩個環境變數的情況下,會是以當前的作業系統/CPU 架構為建置的對象。比方說最常見的一種系統組合是linux/amd64,所以在那樣的環境下執行上述指令,所能夠獲得的當然是只能夠在 x86_64 CPU 的 Linux 作業系統上執行的可執行檔。

作業系統/CPU 架構的組合這樣的說法實在太過拗口,但是這偏偏又是 Golang 的一個重要概念。以下都以系統組合為代稱。

我們預期 Hoddarla 專案的成品是作業系統的系統影像,這與 Golang 常見的用法有著非常大的出入。回顧快速上手章節,我們在 Makefile 裡面,使用了以下指令來建置 Hoddarla 的系統影像(system image):

GOOS=opensbi GOARCH=riscv64 go build -ldflags='-R 0x1000 -T -0x3fffffe000' -o ethanol

為什麼稱系統影像,而非系統映像?筆者其實沒有偏好,只是認為這次系列文既然要堂堂正正,就盡量採取正體中文寫作,且英文名詞盡量以國家教育研究院的翻譯名詞為依歸。系列標題的發語詞予焦啦以台語正字表示,而非使用乎乾啦,也是一樣的精神。然而筆者也意識到國家教育研究院的部分譯名可能無法為普羅大眾接受,所以遇到比較悖離習慣的翻譯的話,會像這樣在註解中評述。

參考昨日上傳的 debut 分支的話,這段編譯命令位在 ethanol 資料夾內的 Makefile 之中。請各位讀者先暫時按捺關於 -ldflags 參數的好奇心,這會緊接著在兩三日之內說明。又,-o 參數的意義與一般編譯器的用法相同,能夠將產物命名為預設檔名之外的字串,此例即為 ethanol

Hoddarla 專案為什麼將系統影像檔命名為 ethanol?這是因為在筆者的規劃之中,Hoddarla 是作業系統專案本身,其中,ethanol(酒精的正式學名:乙醇)是它的核心。這次的系列文規劃中,不太可能讓使用者空間有登場的機會,所以 Hoddarla 和 ethanol 這兩個名詞之間可能會有點混淆。但後面的篇章都會較偏重於 ethanol 本身。

看起來與一般 Golang 開發流程並沒有什麼不同,對吧?但是相信大部分讀者,根本沒聽說過 OpenSBI 這個作業系統;就算聽說過,也不太可能認為這是個作業系統。且讓我們從這裡說起。

OpenSBI:RISC-V 世界的通用韌體實作

OpenSBI 是當今運行 RISC-V 的 Unix-like 系統幾乎都會採用的機器模式(M-mode,Machine Mode)系統軟體,負責平台等級的控制與整個系統中更高權限等級的控制。開機時會由 OpenSBI 在機器模式預先為作業系統模式(S-mode,Supervisor Mode)設置好一些暫存器和機制,比方說 medelegmideleg 兩個系統暫存器,就可以設定有哪些中斷或是例外可以交由作業系統模式的軟體處理。以最成熟的 Linux 來講,RISC-V 系統開機時,也是先經過機器模式的 OpenSBI,再透過權限轉移機制將系統開進作業系統模式。

必須強調的是,這件事情和 Golang 一點關係都沒有。目前以 go 語言社群的動向來看,也沒有要支援低階系統軟體寫作的樣貌。除了 Windows、Plan9 和 wasm/js 之類比較特異的系統組合之外,Golang 通常都是用以開發運行 Unix-like 作業系統上的應用程式。除了應用程式本身,Golang 的執行期函式庫、垃圾回收機制等等都必須仰賴作業系統核心的一些功能,如虛擬記憶體或是系統呼叫。今天我們的目標是以 Golang 開發作業系統的話,就表示我們在開拓一個新的抽象層,因為顯然我們不可能將 OpenSBI 這樣的專案改成更接近 POSIX(可攜式作業系統界面)標準的樣貌,也不可能有來自機器模式的虛擬記憶體支援。但是,經過我們修改的 Golang 工具鏈,又是要用以開發作業系統的。

總之,這裡的目標很明確,那就是支援 opensbi 這個可以被辨識的新作業系統,並設定它合法的架構只有 riscv64

參考前年的拙作 的話,這裡很容易可以找到下手的目標:Golang 的執行期環境函式庫(runtime)。在應用程式本身的 main 函式得以啟動之前,有些啟動時的設置是與該應用程式所在的系統組合(像是 linux/amd64 或 opensbi/riscv64)有關的,其中往往也有許多和系統較底層有關的部份。我們可以在** go/src/runtime 資料夾下觀察到比其他函式庫還要多的組合語言檔**,如 rt0_linux_amd64.s 或是 sys_freebsd_arm64.s。Golang 本身畢竟還是高階語言,有些底層設置如 ABI 的調整之類的功能不在 Golang 的語法所提供的功能之內,所以需要這種方式來補足基礎的控制。也就是說我們可以直接在 runtime 函式庫當中尋找可以當作範本的檔案,複製出來並將原本的作業系統改成 opensbi 即可。

又,我們也可以觀察這資料夾底下檔案的新舊程度,尋找最近加入的作業系統,並據以觀察那些移植階段時,非常初期的一些 git 送交紀錄(commit),以如法炮製新增一個作業系統的必經步驟。

但,更有趣的就是直接來試試看!首先我們隨意創造一個 hw.go

package main

import "fmt"

func main() {
        fmt.Println("Hello World!")
}

整個系列文都會先以這個 Hellow World 檔案當作 ethanol 核心的基本範例,位在 Hoddarla 專案底下的 ethanol/ethanol.go。也許看起來實在過於基本,但想要在今年的鐵人賽就涵蓋所有作業系統的面向實在是不可能的任務,所以請容筆者先用這個範例來進行初階的目標。

正面嘗試

對於發行版可以安裝的預設 go 工具鏈來講,隨意給定它一個系統組合的話,它只會顯示

$ GOOS=opensbi GOARCH=riscv64 go run main.go                                                                                         
cmd/go: unsupported GOOS/GOARCH pair opensbi/riscv64

哪怕是看起來很像是那麼一回事的 opensbi/riscv64 也不例外。要解決這個問題,我們可以找找看這個錯誤訊息的來源在哪裡:

$ grep 'unsupported GOOS/GOARCH pair' -R ./go/src/cmd
... # 中間這裡很多歸屬在 testdata 資料夾下的內容,我就先忽略了
go/src/cmd/go/internal/work/action.go:               return fmt.Errorf("unsupported GOOS/GOARCH pair %s/%s", goos, goarch)

為什麼知道要在 go/src/cmd 底下找而不是 go/src 甚至 go 的地毯式搜尋呢?這是因為整個 go 語言軟體庫包含了自己的工具鏈,實務上也產出許多可各自執行的程式;也就是說,這是個大型複合專案,裡面其實有很多個 package main,除了可以獨立作戰的連結器和組譯器,還有其他許多,都歸屬在 cmd 資料夾底下。當然,通常的 Golang 專案開發所連結到的那些標準函式庫,不會包含這些帶有 package main 的部份。

只有一個選項,那這應該就是答案了。這個檔案的所在位置在 go/src/cmd/go 就表示,這個檔案會被 go 指令本身使用到。Golang 檔案階層架構符合直覺且易懂,在第一層的內容中是主要的內容,而 internal 以下的各個部份則是所需的內部功能;但實際上,有些可能本身的功能就相當於一整個次指令,如 cmd/go/internal/get 包含大部分 go get 指令的功能,或是 cmd/go/internal/vet 實作 go vet 指令的功能一樣。cm/go/internal/work 則比較通用,被使用在許多其他的次指令中。

回報錯誤的片段如下,

311 func CheckGOOSARCHPair(goos, goarch string) error {
312         if _, ok := cfg.OSArchSupportsCgo[goos+"/"+goarch]; !ok && cfg.BuildContext.Compiler == "gc" {                              
313                 return fmt.Errorf("unsupported GOOS/GOARCH pair %s/%s", goos, goarch)                                               
314         }
315         return nil
316 }

顯然要印出錯誤的條件是所使用的編譯器為 gc,並且goos/goarch 不是 cfg.OSArchSupportsCgo 這個映射(map)物件的合法索引值。那要去哪裡檢視這個索引值的列表呢?直接搜尋找到 ./go/src/cmd/go/internal/cfg/zosarch.go

  1 // Code generated by go tool dist; DO NOT EDIT.
  2 
  3 package cfg
  4
  5 var OSArchSupportsCgo = map[string]bool{
  6         "aix/ppc64": true,
  7         "android/386": true,
  ...

這個檔案明白告訴我們這是生成的程式碼,手動修改的話也是沒有意義的,並且告知我們這是由 dist 工具生成。有過編譯 Golang 工具鏈經驗的讀者應該對這個工具的名稱感到眼熟,因為這正是工具鏈生成之時,需要編成的第一個軟體包組合,可說是 go 語言自身內部的根源軟體元件。根據文件,dist 的業務範圍是啟動(bootstrap)、建置與測試工具鏈,也可以透過 go tool dist 查看其他的子指令。這樣循線追查,就能夠找到 ./cmd/dist/build.go 檔案中生成這個映射陣列的位置。事實上還是稍微有點迂迴,因為是在 gentab 這個物件陣列之中紀錄 zosarch.go 字串,以及一個對應的函式指標 mkzosarch。這個物件陣列還包含了其他的自動生成檔案,註解中描述為「非常簡單的生成檔案」。

mk 開頭的函式或是指令檔在 go 語言裡面都有自動生成某些組態的功能。

mkzosarch 的內容是

 76 // mkzcgo writes zosarch.go for cmd/go.
 77 func mkzosarch(dir, file string) {
 78         // sort for deterministic zosarch.go file
 79         var list []string
 80         for plat := range cgoEnabled {
 81                 list = append(list, plat)
 82         }
 83         sort.Strings(list)
 84
 85         var buf bytes.Buffer
 86         fmt.Fprintf(&buf, "// Code generated by go tool dist; DO NOT EDIT.\n\n")
 87         fmt.Fprintf(&buf, "package cfg\n\n")
 88         fmt.Fprintf(&buf, "var OSArchSupportsCgo = map[string]bool{\n") 
 89         for _, plat := range list {
 90                 fmt.Fprintf(&buf, "\t%q: %v,\n", plat, cgoEnabled[plat])
 91         }
 92         fmt.Fprintf(&buf, "}\n")
 93
 94         writefile(buf.String(), file, writeSkipSame)
 95 }

這裡有點兼用的意味,因為用來參考的 cgoEnabled 陣列原先只是紀錄 cgo 的支援性與否的一個陣列而已,但在使用上可以當作一組作業系統與架構是否建檔過的參考。

所以,也可以從另一個面向來看 dist 工具是整個工具鏈建置的第一步的原因。假設今天人們是要在某個已經很成熟且運算資源豐富的環境組合(如 linux/amd64)之下開發某個新的作業系統與架構的組合(如 opensbi/riscv64),dist 工具的程式碼中的 cgoEnabled 之中當然會包含到新的那個組合。反之,在建置啟動工具鏈的時候,要是 dist 沒有率先更新為新版供後續步驟使用,那麼後面的流程自然不會認得這個新組合。

至於這個 cgo 本身代表的意義,大部分時候是用來指稱 C 語言與 go 之間互相使用的界面,但是如果當作子指令使用,也能夠有一些轉換的功能。由於 Hoddarla 的目標自始至終就是要只包含純 Golang(以及這個語言框架內所允許的組合語言),這裡對於 cgo 就都不會太過著墨。

這個 cgoEnabled 陣列又是如何生成?在這個檔案中持續檢索,會發現 mkzcgo 函式裡面也是參考 cgoEnabled 陣列,然後再寫出 cgoEnabled 到另外一個生成檔案。但是這不是雞生蛋蛋生雞,因為前者是定義在 dist 指令所屬的 main 軟體包中的陣列,後者則將會歸屬於 build 軟體包。所以,為了要支援 opensbi/riscv64 這樣的組合,我們首先要讓 dist 認得它,方法就是將它加到 cgoEnabled 之中。重編工具鏈之後,結果是出現了不同的錯誤訊息

$ GOOS=opensbi GOARCH=riscv64 go build /tmp/main.go
go tool compile: exit status 1
compile: unknown goos opensbi

面對編譯器

上一小節我們發現,一旦 dist 工具放行這個系統組合之後,編譯器想要認真做好分內工作時,就會找不到 opensbi 這個作業系統。順著 unknown goos 的訊息去找,雖然沒有在 go/src/cmd/compile 裡面找到,但是在 src/cmd/internal/obj/sym.go 找到了

 52         if err := ctxt.Headtype.Set(objabi.GOOS); err != nil {
 53                 log.Fatalf("unknown goos %s", objabi.GOOS)
 54         }

src/cmd/internal 裡面有各個 Golang 子指令共享的結構或是定義,所以這裡筆者才會在 src/cmd/compile 裡面找不到之後,往該資料夾尋找。

從程式碼可以看懂 objabi.GOOS 應該就是我們傳入的 opensbi,事實上也不難檢驗,只要針對 objabi 資料夾檢索一番

$ grep GOOS -R ./cmd/internal/objabi
./cmd/internal/objabi/zbootstrap.go:const defaultGOOS = runtime.GOOS
...
./cmd/internal/objabi/util.go:  GOOS     = envOr("GOOS", defaultGOOS)
...

就可以看到 GOOS 是從環境變數而來的。要打通這個環節,我們必須從產生錯誤的根本之處下手,也就是 ctxt.Headtype.Set(objabi.GOOS) 的失敗。這個 Set 函式存在於 cmd/internal/objabi/head.go,可以看到一整排的作業系統字串定義

 53 func (h *HeadType) Set(s string) error {
 54         switch s {
 55         case "aix":
 56                 *h = Haix
 57         case "darwin", "ios":
 58                 *h = Hdarwin
 59         case "dragonfly":
 60                 *h = Hdragonfly
 61         case "freebsd":
 62                 *h = Hfreebsd
 63         case "js":
 64                 *h = Hjs
 65         case "linux", "android":
 66                 *h = Hlinux
...

失敗的原因就是,字串 opensbi 並不存在這個 switch-case 結構體之中。從這裡的內容看來,我們應該補上的東西也蠻單純的,就是指派一個 Hopensbi 給型別為 HeadType 指標的 h,這個值也跟其他作業系統一樣是個常數。除了 Set 之外,也還有另外一個 String 函式,是作反向的操作。

又,其它作業系統的代表符號 Hxxxx 作為常數(const)被定義在這個原始碼檔案的開頭處,因此我們打算新加的 Hopensbi 當然也得在這裡定義才行。

這三組系統組合相關的資訊一併補上之後,錯誤訊息再度隨之改變:

$ GOOS=opensbi GOARCH=riscv64 go build /tmp/main.go
# syscall
src/syscall/syscall.go:50:16: undefined: EINVAL
src/syscall/syscall.go:82:11: undefined: Timespec
src/syscall/syscall.go:88:11: undefined: Timeval
src/syscall/syscall.go:93:11: undefined: Timespec
src/syscall/syscall.go:98:11: undefined: Timeval
# runtime/internal/sys
src/runtime/internal/sys/stubs.go:16:61: undefined: GoosAix

看起來有兩種不一樣的錯誤。我們這裡先看與系統組合設定有關的 GoosAix

Undefined: GoosAix

src/runtime/internal/sys/arch.go 中,GoosAix 變數確實有被用到。它是一個布林值(Boolean),代表 Golang 運作的系統是否為 AIX 作業系統的意思。

const StackGuardMultiplier = StackGuardMultiplierDefault*(1-GoosAix) + 2*GoosAix

這個 StackGuardMultiplier 與 Golang 保護堆疊的功能有關。此處顯然是表示,若所運作的作業系統不是 AIX,那麼就使用預設的變數;若是正好是 AIX,那麼就設置為 2。

既然我們準備要使用的作業系統是新加入的 OpenSBI,難道 Golang 無法自動判斷它不是 AIX 嗎?看來目前是這樣沒錯。搜尋這個變數的話,會發現它存在很多地方:

$ grep GoosAix -R ./runtime                                          
./runtime/export_test.go:var BaseChunkIdx = ChunkIdx(chunkIndex(((0xc000*pageAlloc64Bit + 0x100*pageAlloc32Bit) * pallocChunkBytes) + ar
enaBaseOffset*sys.GoosAix))                                         
./runtime/internal/sys/arch.go:const StackGuardMultiplier = StackGuardMultiplierDefault*(1-GoosAix) + 2*GoosAix
./runtime/internal/sys/zgoos_aix.go:const GoosAix = 1               
./runtime/internal/sys/zgoos_android.go:const GoosAix = 0     
./runtime/internal/sys/zgoos_js.go:const GoosAix = 0
./runtime/internal/sys/zgoos_openbsd.go:const GoosAix = 0
./runtime/internal/sys/zgoos_windows.go:const GoosAix = 0
./runtime/internal/sys/zgoos_hurd.go:const GoosAix = 0
./runtime/internal/sys/zgoos_freebsd.go:const GoosAix = 0
./runtime/internal/sys/zgoos_ios.go:const GoosAix = 0
./runtime/internal/sys/zgoos_illumos.go:const GoosAix = 0
./runtime/internal/sys/zgoos_darwin.go:const GoosAix = 0
./runtime/internal/sys/zgoos_dragonfly.go:const GoosAix = 0
./runtime/internal/sys/zgoos_solaris.go:const GoosAix = 0
./runtime/internal/sys/zgoos_plan9.go:const GoosAix = 0
./runtime/internal/sys/zgoos_zos.go:const GoosAix = 0
./runtime/internal/sys/zgoos_netbsd.go:const GoosAix = 0
./runtime/internal/sys/zgoos_linux.go:const GoosAix = 0
./runtime/malloc.go:    arenaBaseOffset = 0xffff800000000000*sys.GoarchAmd64 + 0x0a00000000000000*sys.GoosAix

除了前後三個以 sys.GoosAix 的形式被使用之外,其餘的都具備很統一的格式,都是 zgoos_作業系統名稱.go 裡面將 GoosAix 定義成零。與前面章節類似,z開頭的 Golang 檔案往往是自動生成的結果。隨意參考其中的任何一個來看看,比方說 src/runtime/internal/sys/zgoos_linux.go

// Code generated by gengoos.go using 'go generate'. DO NOT EDIT.
...

這第一行的註解本身就是子指令 go generatesrc/runtime/internal/sys/gengoos.go 的內容為基礎生成的。這個檔案內的原始碼很短也很事務性,這裡略過它的內容,但指出兩個重要的部份:

  5 //go:build ignore   
  6 // +build ignore
 ...
 21 func main() { 
 22         data, err := os.ReadFile("../../../go/build/syslist.go")

前面兩行是給 Golang 的各種工具查看的指引(directive),之所以有兩行看起來很像的,是因為第 5 行那種語法是在 Go 1.17 之後會導入的新語法,而最近的幾個版本都會兼容兩種指引語法。雖然不需要知道 Golang 各種指引的細節,但這裡很容易可以理解是要 Golang 工具在建置整個 Golang 的時候忽略這個檔案。

一般來說,執行期(runtime)原始碼資料夾底下的東西都與作業系統或是 CPU 架構高度相關,因此只要系統組合對了,都應該一併建置,但我們所看到的 gengoos.go 正好就是個特例,呼應 21 行可知,這個檔案自己有自己的 main 函式,並有自己獨立的功能,那就是生成 zgoos 系列檔案。

其中明示的是,src/go/build/syslist.go 檔案,

// Copyright 2011 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package build

// List of past, present, and future known GOOS and GOARCH values.
// Do not remove from this list, as these are used for go/build filename matching.

const goosList = "aix android darwin dragonfly freebsd hurd illumos ios js linux nacl netbsd openbsd plan9 solaris windows zos "
const goarchList = "386 amd64 amd64p32 arm armbe arm64 arm64be ppc64 ppc64le mips mipsle mips64 mips64le mips64p32 mips64p32le ppc riscv riscv64 s390 s390x sparc sparc64 wasm "

這就是整個 Golang 工具鏈檢查系統組合時最上游的一個檔案!我們在 goosList 列表當中加入 opensbi,然後在 src/runtime/internal/sys 底下重新執行一次 go generate,可以發現這次產生了新的 zgoos_opensbi.go 檔案,並且其它所有 zgoos 檔案都會包含一行 const GoosOpensbi = 0

再編譯一次,果然原本的 GoosAix 的未定義問題就消失了:

$ GOOS=opensbi GOARCH=riscv64 go build ../../ethanol/hw.go 
# syscall
syscall/syscall.go:50:16: undefined: EINVAL
syscall/syscall.go:81:11: undefined: Timespec
syscall/syscall.go:86:11: undefined: Timeval
syscall/syscall.go:91:11: undefined: Timespec
syscall/syscall.go:96:11: undefined: Timeval
# runtime
runtime/alg.go:341:2: undefined: getRandomData
runtime/alg.go:351:2: undefined: getRandomData
runtime/proc.go:142:17: undefined: sigset
runtime/runtime2.go:519:16: undefined: gsignalStack
runtime/runtime2.go:520:16: undefined: sigset
runtime/runtime2.go:597:2: undefined: mOS
runtime/sigqueue.go:54:15: undefined: _NSIG
runtime/sigqueue.go:55:15: undefined: _NSIG
runtime/sigqueue.go:56:15: undefined: _NSIG
runtime/sigqueue.go:57:15: undefined: _NSIG
runtime/alg.go:351:2: too many errors

再檢查一下 src/cmd/dist

確實我們已經可以成功編出一隻工具鏈,但還是應該再檢查一下這裡是否已經成功登錄 opensbi 為一個 Golang 認可的作業系統。檢查的方法也很簡單,就是查詢其他的作業系統是否有被紀錄在任何 opensbi 還不存在的地方。筆者這裡選擇 aix 作業系統來當作檢索的對象,因為它的使用者較少,應不致於像 linux 那樣遍布 Golang 程式碼中。

$ grep aix -Ri ./cmd/dist | grep -v '://' | grep -v '.s:'
./cmd/dist/test.go:     case "aix-ppc64",
./cmd/dist/test.go:     if goos == "aix" {
./cmd/dist/test.go:             case "aix-ppc64",
./cmd/dist/test.go:             case "aix/ppc64",
./cmd/dist/test.go:     case "aix-ppc64",
./cmd/dist/test.go:             case "aix-ppc64", "netbsd-386", "netbsd-amd64":
./cmd/dist/main.go:     case "aix":
./cmd/dist/main.go:             // uname -m doesn't work under AIX
./cmd/dist/build.go:    "aix",
./cmd/dist/build.go:    "aix/ppc64":       true,

檢索條件中排除了註解內容與架構相依的組譯檔。檢索出來的結果,test.go 是測試用檔案,我們可以忽略;main.go 為 Golang 的 dist 指令的入口,在這裡出現作業系統或是架構相關的判斷,通常是一些特例的排除,我們也可以忽略。因此就只剩下我們稍早也改寫過的 cgoEnabled 陣列,還有另外一個 "aix" 字串。

進一步檢驗,可以發現該字串是在 okgoos 陣列之中:

// The known operating systems.
var okgoos = []string{
        "darwin",
        "dragonfly",
        "illumos",
        "ios",
        "js",
        "linux",
        ...
}

這個陣列現在還沒有 opensbi 這個成員,按照註解來看,這會使得 opensbi 不是一個正式被認識的作業系統。這與我們先前的實驗卻又矛盾了:明明 opensbi/riscv64 組合已經可以成功支援了不是嗎?

搜尋一下 okgoos 的用法可以發現,這會影響到許多其它 Golang 開發的層面,所以我們這裡還是先將 opensbi 加入,完成今天的分量。

小結

予焦啦!今天讓 opensbi/riscv64 組合能夠被 dist 工具識別,且編譯器已經摩拳擦掌準備要打造一個可執行檔了!現在遇到一堆未定義的符號。這個部分已經上傳到 github 了,歡迎有興趣的讀者一起來玩一玩。

畢竟我們從一個乾淨的 Golang 原始碼儲存庫(source code repository)出發,目前幾乎什麼實質內容也還沒有開始加進去,也是蠻合理的。


上一篇
予焦啦!目錄、快速上手與前言
下一篇
予焦啦!產出可執行檔
系列文
予焦啦!Hoddarla 專案起步:使用 Golang 撰寫 RISC-V 作業系統的初步探索33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言